iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
佛心分享-IT 人自學之術

欸欸!! 這是我的學習筆記系列 第 21

Day21 - CORS - 跨域資源共享

  • 分享至 

  • xImage
  •  

前言

昨天寫 Helmet 的時候順便提到了 CORS。就是這個在開發時讓人頭痛又不陌生的東西「Access to XMLHttpRequest has been blocked by CORS policy」,今天就來整理 CORS 到底是什麼。

什麼是 CORS?

CORS 全名是 Cross-Origin Resource Sharing(跨來源資源共享)。聽起來很專業,但其實概念很簡單:就是讓瀏覽器知道「這個網站可以跟那個網站交換資料」。

為什麼需要這個機制?因為瀏覽器有一個叫做「同源政策」(Same-Origin Policy)的安全機制。

什麼是同源政策?

同源政策是瀏覽器最基本的安全功能之一。它規定:一個網頁的 JavaScript 只能存取「同源」的資源。

什麼叫「同源」?必須同時滿足三個條件:

  1. 協定(Protocol)相同:http vs https
  2. 網域(Domain)相同:example.com vs api.example.com
  3. 埠號(Port)相同:3000 vs 8080

來看幾個例子:

原始網址:https://example.com:443/page

同源:
✓ https://example.com:443/api/users
✓ https://example.com:443/about

不同源:
✗ http://example.com:443/page        (協定不同)
✗ https://api.example.com:443/page   (網域不同)
✗ https://example.com:8080/page      (埠號不同)

為什麼需要同源政策?

假設沒有同源政策,你登入了銀行網站,然後又打開了一個惡意網站。這個惡意網站的 JavaScript 就可以直接存取銀行網站的 cookie,然後用你的身分轉帳。

有了同源政策,惡意網站的 JavaScript 就無法存取銀行網站的資料,因為它們不同源。

前後端分離怎麼辦?

現代網頁開發常常是前後端分離:

  • 前端跑在 http://localhost:3000
  • 後端 API 跑在 http://localhost:8080

這兩個網址明顯不同源(埠號不同),所以前端的請求會被瀏覽器擋下來。這時候就需要 CORS 了。

CORS 就是一套機制,讓後端可以告訴瀏覽器:「我允許某些不同源的網站來存取我的資源」。

CORS 怎麼運作?

CORS 主要透過 HTTP 標頭來溝通。最重要的幾個標頭是:

1. Access-Control-Allow-Origin

這是最關鍵的標頭,告訴瀏覽器哪些來源可以存取資源。

// 允許所有來源(不安全,別在正式環境這樣用)
Access-Control-Allow-Origin: *

// 只允許特定來源
Access-Control-Allow-Origin: https://example.com

2. Access-Control-Allow-Methods

指定允許的 HTTP 方法。

Access-Control-Allow-Methods: GET, POST, PUT, DELETE

3. Access-Control-Allow-Headers

指定允許的請求標頭。

Access-Control-Allow-Headers: Content-Type, Authorization

4. Access-Control-Allow-Credentials

是否允許發送 cookies。

Access-Control-Allow-Credentials: true

Preflight Request(預檢請求)

這是 CORS 中比較特別的部分。對於某些「複雜」的請求,瀏覽器會先發送一個 OPTIONS 請求,問後端「我可以發這個請求嗎?」

什麼樣的請求會觸發 preflight?

  • 使用 PUT、DELETE、PATCH 等方法
  • Content-Type 不是以下三種之一:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 有自訂的 headers(像 Authorization

流程是這樣的:

1. 瀏覽器:「我想發一個 POST 請求,帶有 Authorization header,可以嗎?」
   (發送 OPTIONS 請求)

2. 後端:「可以啦,哪次不可以」
   (回傳 CORS 標頭)

3. 瀏覽器:「好,已發」
   (發送實際的 POST 請求)

在 Express 中使用 CORS

最簡單的方法是用 cors 這個套件:

npm install cors

基本使用

const express = require('express');
const cors = require('cors');

const app = express();

// 允許所有來源(開發環境可以,正式環境別這樣)
app.use(cors());

app.get('/api/data', (req, res) => {
  res.json({ message: 'Hello CORS!' });
});

app.listen(8080);

限制特定來源

// 只允許特定網域
app.use(cors({
  origin: 'https://example.com'
}));

// 允許多個網域
app.use(cors({
  origin: ['https://example.com', 'https://app.example.com']
}));

// 動態決定
const allowedOrigins = ['https://example.com', 'https://app.example.com'];

app.use(cors({
  origin: function (origin, callback) {
    // 允許沒有 origin 的請求(像 Postman、curl)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
}));

允許 Credentials

app.use(cors({
  origin: 'https://example.com',
  credentials: true  // 允許發送 cookies
}));

注意:如果 credentials: true,就不能用 origin: '*',必須明確指定來源。

針對特定路由

// 只有特定路由需要 CORS
app.get('/api/public', cors(), (req, res) => {
  res.json({ message: 'Public API' });
});

// 其他路由不套用 CORS
app.get('/api/internal', (req, res) => {
  res.json({ message: 'Internal API' });
});

完整配置

const corsOptions = {
  origin: function (origin, callback) {
    const allowedOrigins = [
      'https://example.com',
      'https://app.example.com'
    ];
    
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400  // preflight 結果快取 24 小時
};

app.use(cors(corsOptions));

常見問題排查

問題 1:還是出現 CORS 錯誤

檢查清單:

  1. 確認後端有正確設定 CORS
  2. 確認 origin 設定正確(注意有沒有結尾的斜線)
  3. 檢查是否有 preflight 請求,後端有沒有處理 OPTIONS
  4. 如果用了 credentials,確認 origin 不是 *

問題 2:OPTIONS 請求失敗

可能是其他中介軟體在 cors 之前處理了請求。確保 cors 在最前面:

app.use(cors());
app.use(express.json());
// ... 其他中介軟體

問題 3:開發環境可以,正式環境不行

檢查環境變數設定:

const allowedOrigins = process.env.NODE_ENV === 'production'
  ? ['https://example.com']
  : ['http://localhost:3000', 'http://localhost:3001'];

app.use(cors({
  origin: allowedOrigins
}));

問題 4:Cookies 沒有發送

前端也要設定:

// fetch
fetch('https://api.example.com/data', {
  credentials: 'include'
});

// axios
axios.get('https://api.example.com/data', {
  withCredentials: true
});

後端:

app.use(cors({
  origin: 'https://example.com',
  credentials: true
}));

手動設定 CORS(不用套件)

如果想更細緻的控制,也可以手動設定:

app.use((req, res, next) => {
  const allowedOrigins = ['https://example.com'];
  const origin = req.headers.origin;
  
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  
  // 處理 preflight
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  
  next();
});

開發環境的小技巧

1. 使用代理

在前端專案中設定代理,就不用處理 CORS 了:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}
// React (package.json)
{
  "proxy": "http://localhost:8080"
}

2. Chrome 擴充套件

開發時可以用 CORS 相關的 Chrome 擴充套件暫時關閉 CORS 檢查。但記得:

  • 只在開發時用
  • 用完記得關掉
  • 正式環境一定要正確設定 CORS

安全考量

不要用 origin: '*'

除非你的 API 真的是完全公開的,不然不要這樣設定。這等於讓所有網站都可以存取你的 API。

白名單要明確

不要用正規表達式去比對 origin,容易出錯。用陣列明確列出允許的網域。

// 不好的做法
origin: /\.example\.com$/

// 好的做法
origin: [
  'https://www.example.com',
  'https://app.example.com',
  'https://admin.example.com'
]

注意子網域

https://example.comhttps://sub.example.com 是不同源的。如果需要允許所有子網域,要明確列出或用函式判斷。

Credentials 要小心

如果設定了 credentials: true,一定要確保 origin 的白名單是正確的。不然其他網站可以帶著使用者的 cookie 存取你的 API。


上一篇
Day20 - Helmet - 安全標頭設定
系列文
欸欸!! 這是我的學習筆記21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言